// The MIT License (MIT)
//
// Copyright (c) 2017 Firebase
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

import * as express from "express";

import { ResetValue } from "../common/options";
import { Expression, SecretParam } from "../params/types";
import { EventContext } from "./cloud-functions";
import {
  DeploymentOptions,
  INGRESS_SETTINGS_OPTIONS,
  MAX_NUMBER_USER_LABELS,
  MAX_TIMEOUT_SECONDS,
  RuntimeOptions,
  SUPPORTED_REGIONS,
  VALID_MEMORY_OPTIONS,
  VPC_EGRESS_SETTINGS_OPTIONS,
} from "./function-configuration";
import * as analytics from "./providers/analytics";
import * as auth from "./providers/auth";
import * as database from "./providers/database";
import * as firestore from "./providers/firestore";
import * as https from "./providers/https";
import * as pubsub from "./providers/pubsub";
import * as remoteConfig from "./providers/remoteConfig";
import * as storage from "./providers/storage";
import * as tasks from "./providers/tasks";
import * as testLab from "./providers/testLab";

/**
 * Assert that the runtime options passed in are valid.
 * @param runtimeOptions object containing memory and timeout information.
 * @throws { Error } Memory and TimeoutSeconds values must be valid.
 */
function assertRuntimeOptionsValid(runtimeOptions: RuntimeOptions): boolean {
  const mem = runtimeOptions.memory;
  if (mem && typeof mem !== "object" && !VALID_MEMORY_OPTIONS.includes(mem)) {
    throw new Error(
      `The only valid memory allocation values are: ${VALID_MEMORY_OPTIONS.join(", ")}`
    );
  }
  if (
    typeof runtimeOptions.timeoutSeconds === "number" &&
    (runtimeOptions.timeoutSeconds > MAX_TIMEOUT_SECONDS || runtimeOptions.timeoutSeconds < 0)
  ) {
    throw new Error(`TimeoutSeconds must be between 0 and ${MAX_TIMEOUT_SECONDS}`);
  }

  if (
    runtimeOptions.ingressSettings &&
    !(runtimeOptions.ingressSettings instanceof ResetValue) &&
    !INGRESS_SETTINGS_OPTIONS.includes(runtimeOptions.ingressSettings)
  ) {
    throw new Error(
      `The only valid ingressSettings values are: ${INGRESS_SETTINGS_OPTIONS.join(",")}`
    );
  }

  if (
    runtimeOptions.vpcConnectorEgressSettings &&
    !(runtimeOptions.vpcConnectorEgressSettings instanceof ResetValue) &&
    !VPC_EGRESS_SETTINGS_OPTIONS.includes(runtimeOptions.vpcConnectorEgressSettings)
  ) {
    throw new Error(
      `The only valid vpcConnectorEgressSettings values are: ${VPC_EGRESS_SETTINGS_OPTIONS.join(
        ","
      )}`
    );
  }

  validateFailurePolicy(runtimeOptions.failurePolicy);
  const serviceAccount = runtimeOptions.serviceAccount;
  if (
    serviceAccount &&
    !(
      serviceAccount === "default" ||
      serviceAccount instanceof ResetValue ||
      serviceAccount instanceof Expression ||
      serviceAccount.includes("@")
    )
  ) {
    throw new Error(
      `serviceAccount must be set to 'default', a string expression, a service account email, or '{serviceAccountName}@'`
    );
  }

  if (runtimeOptions.labels) {
    // Labels must follow the rules listed in
    // https://cloud.google.com/resource-manager/docs/creating-managing-labels#requirements

    if (Object.keys(runtimeOptions.labels).length > MAX_NUMBER_USER_LABELS) {
      throw new Error(
        `A function must not have more than ${MAX_NUMBER_USER_LABELS} user-defined labels.`
      );
    }

    // We reserve the 'deployment' and 'firebase' namespaces for future feature development.
    const reservedKeys = Object.keys(runtimeOptions.labels).filter(
      (key) => key.startsWith("deployment") || key.startsWith("firebase")
    );
    if (reservedKeys.length) {
      throw new Error(
        `Invalid labels: ${reservedKeys.join(
          ", "
        )}. Labels may not start with reserved names 'deployment' or 'firebase'`
      );
    }

    const invalidLengthKeys = Object.keys(runtimeOptions.labels).filter(
      (key) => key.length < 1 || key.length > 63
    );
    if (invalidLengthKeys.length > 0) {
      throw new Error(
        `Invalid labels: ${invalidLengthKeys.join(
          ", "
        )}. Label keys must be between 1 and 63 characters in length.`
      );
    }

    const invalidLengthValues = Object.values(runtimeOptions.labels).filter(
      (value) => value.length > 63
    );
    if (invalidLengthValues.length > 0) {
      throw new Error(
        `Invalid labels: ${invalidLengthValues.join(
          ", "
        )}. Label values must be less than 64 charcters.`
      );
    }

    // Keys can contain lowercase letters, foreign characters, numbers, _ or -. They must start with a letter.
    const validKeyPattern = /^[\p{Ll}\p{Lo}][\p{Ll}\p{Lo}\p{N}_-]{0,62}$/u;
    const invalidKeys = Object.keys(runtimeOptions.labels).filter(
      (key) => !validKeyPattern.test(key)
    );
    if (invalidKeys.length > 0) {
      throw new Error(
        `Invalid labels: ${invalidKeys.join(
          ", "
        )}. Label keys can only contain lowercase letters, international characters, numbers, _ or -, and must start with a letter.`
      );
    }

    // Values can contain lowercase letters, foreign characters, numbers, _ or -.
    const validValuePattern = /^[\p{Ll}\p{Lo}\p{N}_-]{0,63}$/u;
    const invalidValues = Object.values(runtimeOptions.labels).filter(
      (value) => !validValuePattern.test(value)
    );
    if (invalidValues.length > 0) {
      throw new Error(
        `Invalid labels: ${invalidValues.join(
          ", "
        )}. Label values can only contain lowercase letters, international characters, numbers, _ or -.`
      );
    }
  }

  if (typeof runtimeOptions.invoker === "string" && runtimeOptions.invoker.length === 0) {
    throw new Error("Invalid service account for function invoker, must be a non-empty string");
  }
  if (runtimeOptions.invoker !== undefined && Array.isArray(runtimeOptions.invoker)) {
    if (runtimeOptions.invoker.length === 0) {
      throw new Error("Invalid invoker array, must contain at least 1 service account entry");
    }
    for (const serviceAccount of runtimeOptions.invoker) {
      if (serviceAccount.length === 0) {
        throw new Error("Invalid invoker array, a service account must be a non-empty string");
      }
      if (serviceAccount === "public") {
        throw new Error(
          "Invalid invoker array, a service account cannot be set to the 'public' identifier"
        );
      }
      if (serviceAccount === "private") {
        throw new Error(
          "Invalid invoker array, a service account cannot be set to the 'private' identifier"
        );
      }
    }
  }

  if (runtimeOptions.secrets !== undefined) {
    const invalidSecrets = runtimeOptions.secrets.filter(
      (s) => !/^[A-Za-z\d\-_]+$/.test(s instanceof SecretParam ? s.name : s)
    );
    if (invalidSecrets.length > 0) {
      throw new Error(
        `Invalid secrets: ${invalidSecrets.join(",")}. ` +
          "Secret must be configured using the resource id (e.g. API_KEY)"
      );
    }
  }

  if ("allowInvalidAppCheckToken" in runtimeOptions) {
    throw new Error(
      'runWith option "allowInvalidAppCheckToken" has been inverted and ' +
        'renamed "enforceAppCheck"'
    );
  }

  return true;
}

function validateFailurePolicy(policy: any) {
  if (typeof policy === "boolean" || typeof policy === "undefined") {
    return;
  }
  if (typeof policy !== "object") {
    throw new Error(`failurePolicy must be a boolean or an object.`);
  }

  const retry = policy.retry;
  if (typeof retry !== "object" || Object.keys(retry).length) {
    throw new Error("failurePolicy.retry must be an empty object.");
  }
}

/**
 * Assert regions specified are valid.
 * @param regions list of regions.
 * @throws { Error } Regions must be in list of supported regions.
 */
function assertRegionsAreValid(regions: (string | Expression<string> | ResetValue)[]): boolean {
  if (!regions.length) {
    throw new Error("You must specify at least one region");
  }
  return true;
}

/**
 * Configure the regions that the function is deployed to.
 * @param regions One of more region strings.
 * @example
 * functions.region('us-east1')
 * @example
 * functions.region('us-east1', 'us-central1')
 */
export function region(
  ...regions: Array<(typeof SUPPORTED_REGIONS)[number] | string | Expression<string> | ResetValue>
): FunctionBuilder {
  if (assertRegionsAreValid(regions)) {
    return new FunctionBuilder({ regions });
  }
}

/**
 * Configure runtime options for the function.
 * @param runtimeOptions Object with optional fields:
 * 1. `memory`: amount of memory to allocate to the function, possible values
 *    are: '128MB', '256MB', '512MB', '1GB', '2GB', '4GB', and '8GB'.
 * 2. `timeoutSeconds`: timeout for the function in seconds, possible values are
 *    0 to 540.
 * 3. `failurePolicy`: failure policy of the function, with boolean `true` being
 *    equivalent to providing an empty retry object.
 * 4. `vpcConnector`: id of a VPC connector in same project and region.
 * 5. `vpcConnectorEgressSettings`: when a vpcConnector is set, control which
 *    egress traffic is sent through the vpcConnector.
 * 6. `serviceAccount`: Specific service account for the function.
 * 7. `ingressSettings`: ingress settings for the function, which control where a HTTPS
 *    function can be called from.
 *
 * Value must not be null.
 */
export function runWith(runtimeOptions: RuntimeOptions): FunctionBuilder {
  if (assertRuntimeOptionsValid(runtimeOptions)) {
    return new FunctionBuilder(runtimeOptions);
  }
}

export class FunctionBuilder {
  constructor(private options: DeploymentOptions) {}

  /**
   * Configure the regions that the function is deployed to.
   * @param regions One or more region strings.
   * @example
   * functions.region('us-east1')
   * @example
   * functions.region('us-east1', 'us-central1')
   */
  region(
    ...regions: Array<(typeof SUPPORTED_REGIONS)[number] | string | Expression<string> | ResetValue>
  ): FunctionBuilder {
    if (assertRegionsAreValid(regions)) {
      this.options.regions = regions;
      return this;
    }
  }

  /**
   * Configure runtime options for the function.
   * @param runtimeOptions Object with optional fields:
   * 1. `memory`: amount of memory to allocate to the function, possible values
   *    are: '128MB', '256MB', '512MB', '1GB', '2GB', '4GB', and '8GB'.
   * 2. `timeoutSeconds`: timeout for the function in seconds, possible values are
   *    0 to 540.
   * 3. `failurePolicy`: failure policy of the function, with boolean `true` being
   *    equivalent to providing an empty retry object.
   * 4. `vpcConnector`: id of a VPC connector in the same project and region
   * 5. `vpcConnectorEgressSettings`: when a `vpcConnector` is set, control which
   *    egress traffic is sent through the `vpcConnector`.
   *
   * Value must not be null.
   */
  runWith(runtimeOptions: RuntimeOptions): FunctionBuilder {
    if (assertRuntimeOptionsValid(runtimeOptions)) {
      this.options = {
        ...this.options,
        ...runtimeOptions,
      };
      return this;
    }
  }

  get https() {
    if (this.options.failurePolicy !== undefined) {
      console.warn("RuntimeOptions.failurePolicy is not supported in https functions.");
    }

    return {
      /**
       * Handle HTTP requests.
       * @param handler A function that takes a request and response object,
       * same signature as an Express app.
       */
      onRequest: (handler: (req: https.Request, resp: express.Response) => void | Promise<void>) =>
        https._onRequestWithOptions(handler, this.options),
      /**
       * Declares a callable method for clients to call using a Firebase SDK.
       * @param handler A method that takes a data and context and returns a value.
       */
      onCall: (handler: (data: any, context: https.CallableContext) => any | Promise<any>) =>
        https._onCallWithOptions(handler, this.options),
    };
  }

  get tasks() {
    return {
      /**
       * Declares a task queue function for clients to call using a Firebase Admin SDK.
       * @param options Configurations for the task queue function.
       */
      /** @hidden */
      taskQueue: (options?: tasks.TaskQueueOptions) => {
        return new tasks.TaskQueueBuilder(options, this.options);
      },
    };
  }

  get database() {
    return {
      /**
       * Selects a database instance that will trigger the function. If omitted,
       * will pick the default database for your project.
       * @param instance The Realtime Database instance to use.
       */
      instance: (instance: string) => database._instanceWithOptions(instance, this.options),

      /**
       * Select Firebase Realtime Database Reference to listen to.
       *
       * This method behaves very similarly to the method of the same name in
       * the client and Admin Firebase SDKs. Any change to the Database that
       * affects the data at or below the provided `path` will fire an event in
       * Cloud Functions.
       *
       * There are three important differences between listening to a Realtime
       * Database event in Cloud Functions and using the Realtime Database in
       * the client and Admin SDKs:
       * 1. Cloud Functions allows wildcards in the `path` name. Any `path`
       *    component in curly brackets (`{}`) is a wildcard that matches all
       *    strings. The value that matched a certain invocation of a Cloud
       *    Function is returned as part of the `context.params` object. For
       *    example, `ref("messages/{messageId}")` matches changes at
       *    `/messages/message1` or `/messages/message2`, resulting in
       *    `context.params.messageId` being set to `"message1"` or
       *    `"message2"`, respectively.
       * 2. Cloud Functions do not fire an event for data that already existed
       *    before the Cloud Function was deployed.
       * 3. Cloud Function events have access to more information, including
       *    information about the user who triggered the Cloud Function.
       * @param ref Path of the database to listen to.
       */
      ref: <Ref extends string>(path: Ref) => database._refWithOptions(path, this.options),
    };
  }

  get firestore() {
    return {
      /**
       * Select the Firestore document to listen to for events.
       * @param path Full database path to listen to. This includes the name of
       * the collection that the document is a part of. For example, if the
       * collection is named "users" and the document is named "Ada", then the
       * path is "/users/Ada".
       */
      document: <Path extends string>(path: Path) =>
        firestore._documentWithOptions(path, this.options),

      /** @hidden */
      namespace: (namespace: string) => firestore._namespaceWithOptions(namespace, this.options),

      /** @hidden */
      database: (database: string) => firestore._databaseWithOptions(database, this.options),
    };
  }

  get analytics() {
    return {
      /**
       * Select analytics events to listen to for events.
       * @param analyticsEventType Name of the analytics event type.
       */
      event: (analyticsEventType: string) =>
        analytics._eventWithOptions(analyticsEventType, this.options),
    };
  }

  get remoteConfig() {
    return {
      /**
       * Handle all updates (including rollbacks) that affect a Remote Config
       * project.
       * @param handler A function that takes the updated Remote Config template
       * version metadata as an argument.
       */
      onUpdate: (
        handler: (
          version: remoteConfig.TemplateVersion,
          context: EventContext
        ) => PromiseLike<any> | any
      ) => remoteConfig._onUpdateWithOptions(handler, this.options),
    };
  }

  get storage() {
    return {
      /**
       * The optional bucket function allows you to choose which buckets' events
       * to handle. This step can be bypassed by calling object() directly,
       * which will use the default Cloud Storage for Firebase bucket.
       * @param bucket Name of the Google Cloud Storage bucket to listen to.
       */
      bucket: (bucket?: string) => storage._bucketWithOptions(this.options, bucket),

      /**
       * Handle events related to Cloud Storage objects.
       */
      object: () => storage._objectWithOptions(this.options),
    };
  }

  get pubsub() {
    return {
      /**
       * Select Cloud Pub/Sub topic to listen to.
       * @param topic Name of Pub/Sub topic, must belong to the same project as
       * the function.
       */
      topic: (topic: string) => pubsub._topicWithOptions(topic, this.options),
      schedule: (schedule: string) => pubsub._scheduleWithOptions(schedule, this.options),
    };
  }

  get auth() {
    return {
      /**
       * Handle events related to Firebase authentication users.
       */
      user: (userOptions?: auth.UserOptions) => auth._userWithOptions(this.options, userOptions),
    };
  }

  get testLab() {
    return {
      /**
       * Handle events related to Test Lab test matrices.
       */
      testMatrix: () => testLab._testMatrixWithOpts(this.options),
    };
  }
}
